<template>
	<div id="app">
		<div class="file-upload">
			<!-- 上传组件 -->
			<el-upload action="#" :auto-upload="false" :show-file-list="false" :on-change="handleChange">
				<!-- 不显示默认的文件列表 -->
				<!--
				关闭自动上传，由我们控制上传时机
				-->
				<!-- 文件选择变化时的回调 -->
				<el-button slot="trigger" size="small" type="primary">选取文件</el-button>
				<el-button style="margin-left: 10px" size="small" type="success" @click="handleUpload">
					上传到服务器
				</el-button>
				<div slot="tip" class="el-upload__tip" v-if="file">
					{{ filePath ? '已' : '待' }}上传文件：{{ file.name }}
				</div>
			</el-upload>
			<!-- 进度显示 -->
			<div class="progress-box">
				<span>上传进度：{{ percent.toFixed() }}%</span>
				<el-button type="primary" size="mini" @click="handleClickBtn">
					{{ upload | btnTextFilter }}
				</el-button>
			</div>
		</div>
		<!-- 文件路径 -->
		<div class="file-path">
			<a v-for="(item, index) in fileList" :key="index" :href="item" target="_blank">
				{{ index + 1 }}文件名: {{ item }}
				<br />
			</a>
		</div>
	</div>
</template>

<script>
	// 引入 SparkMD5 库用于生成文件的 MD5 值
	import SparkMD5 from 'spark-md5'
	// 引入 axios 用于发送 HTTP 请求
	import axios from 'axios'

	// 定义每个切片的大小为 10MB
	// 为什么设置为 10MB？(前端面试角度) 10 * 1024 * 1024
	// - 平衡网络传输效率和内存消耗
	// - 太小会增加请求次数，太大可能导致单个请求失败影响大
	const SIZE = 10 * 1024 * 1024
	const BaseUrl = 'http://localhost:5000' // 后端服务地址

	export default {
		name: 'App',
		// 过滤器：根据 upload 状态显示按钮文字
		// 为什么使用过滤器？(前端面试角度)
		// - 简化模板中的逻辑判断
		// - 提高代码可读性和复用性
		filters: {
			btnTextFilter(val) {
				return val ? '暂停' : '继续'
			},
		},
		data() {
			return {
				file: null, // 当前选择的文件
				chunkList: [], // 文件切片列表
				hash: '', // 文件的 MD5 哈希值
				percentCount: 0, // 每个切片对总进度的贡献值
				percent: 0, // 当前上传进度百分比
				upload: true, // 上传状态(true表示正在上传)
				filePath: '',
				// 已上传服务器的文件列表
				fileList: [],
				BaseUrl: BaseUrl,
			}
		},
		mounted() {
			console.log(111)

			// 获取文件列表
			this.fetchList()
		},
		methods: {
			formatFilePath(path) {
				// 规范化文件路径,本地打开文件不受网络影响
				// 不依赖 HTTP，不会有路径安全问题
				return path.replace(/\\/g, '/')
			},
			openFile(path) {
				window.open(path, '_blank')
			},
			async fetchList() {
				// 获取文件列表
				// 为什么需要这个方法？(前端面试角度)
				// - 获取文件列表，用于检查文件是否已上传
				// - 避免重复上传相同的文件
				// - 提升用户体验，避免重复上传
				axios
					.get(BaseUrl + '/api/list')
					.then((res) => {
						if (res.data.code === 200) {
							// 获取文件列表成功，将文件列表存储在 chunkList 中
							this.fileList = res.data.data
						}
					})
					.catch((err) => {
						console.log(err)
						this.$message.error(`服务器错误,请稍后重试`)
					})
			},
			/**
			 * 文件选择改变时触发的回调函数
			 * @param {Object} file - Element UI 上传组件传递的文件对象
			 *
			 * 为什么需要这个方法？(前端面试角度)
			 * - 响应用户选择文件的操作
			 * - 重置之前的状态，确保每次都是新的上传流程
			 * - 存储文件对象供后续使用
			 */
			handleChange(file) {
				console.log(this.fileList)
				// 将 data 重置为初始状态，清除上次上传的残留数据-暂时不用
				// 为什么这样做？(前端面试角度)
				// - 避免多次上传之间的状态污染
				// - 确保每次上传都是干净的状态
				// Object.assign(this.$data, this.$options.data())
				this.file = file
			},
			/**
			 * 点击上传按钮后触发的主函数
			 * 负责协调整个文件上传流程：文件读取 -> 切片生成 -> 切片上传 -> 文件合并
			 *
			 * 为什么需要这样的流程？(前端面试角度)
			 * - 实现大文件分片上传，提高成功率
			 * - 支持断点续传和暂停/继续功能
			 * - 提供上传进度反馈，改善用户体验
			 */
			async handleUpload() {
				// 如果没有选择文件则直接返回
				if (!this.file) return

				// 重置进度条
				this.percent = 0

				// 获取文件对象
				const fileObj = this.file.raw

				// 将 File 对象转为 ArrayBuffer
				// 为什么需要转换为 ArrayBuffer？(前端面试角度)
				// - 用于计算文件的 MD5 值，实现文件唯一标识
				// - SparkMD5.ArrayBuffer 只能处理二进制数据
				let buffer
				try {
					buffer = await this.fileToBuffer(fileObj)
				} catch (e) {
					console.log(e)
				}

				// 生成文件切片
				// 为什么需要切片？(前端面试角度)
				// - 大文件上传稳定性：避免因网络波动导致整个文件重新上传
				// - 提高上传效率：支持并发上传多个切片
				// - 断点续传支持：可以只重新上传失败的切片
				this.createChunks(buffer, fileObj)

				// 上传文件切片
				this.uploadChunks()
			},

			/**
			 * 将 File 对象转换为 ArrayBuffer
			 * 主要目的：
			 * 1. 用于生成文件内容的 MD5 哈希值，实现文件唯一标识
			 * 2. SparkMD5.ArrayBuffer 只能处理二进制数据（ArrayBuffer）
			 * 3. 为后续的文件切片和上传做准备
			 * @param {File} fileObj - 原始 File 对象
			 * @returns {Promise<ArrayBuffer>} 返回包含文件二进制数据的 Promise
			 *
			 * 为什么使用 Promise 封装？(前端面试角度)
			 * - FileReader 是异步操作，Promise 化便于在 async/await 中使用
			 * - 提供统一的错误处理机制
			 */
			fileToBuffer(fileObj) {
				return new Promise((resolve, reject) => {
					// 创建 FileReader 实例读取文件
					const fileReader = new FileReader()

					// 读取完成时的回调
					fileReader.onload = (event) => {
						// resolve 读取到的 ArrayBuffer 数据
						resolve(event.target.result)
					}

					// 开始读取文件为 ArrayBuffer
					fileReader.readAsArrayBuffer(fileObj)

					// 读取失败时的回调
					fileReader.onerror = () => {
						reject(new Error('转换文件格式发生错误'))
					}
				})
			},

			/**
			 * 根据文件内容生成切片列表
			 * 流程：
			 * 1. 计算文件总切片数
			 * 2. 提取文件后缀名
			 * 3. 基于文件内容生成唯一哈希值
			 * 4. 按指定大小切分文件并生成切片信息
			 * @param {ArrayBuffer} buffer - 文件的二进制数据，用于计算文件哈希值
			 * @param {File} fileObj - 原始 File 对象，用于获取文件大小、名称等信息及创建切片
			 * @param {Number} chunkSize - 切片大小，默认为 SIZE(10MB)，控制每个切片的字节大小
			 *
			 * 为什么这样设计切片逻辑？(前端面试角度)
			 * - 使用 hash 值作为文件标识，实现秒传功能
			 * - 按固定大小切分，便于后端处理和合并
			 * - 每个切片包含序号，确保合并时顺序正确
			 */
			createChunks(buffer, fileObj, chunkSize = SIZE) {
				// 声明几个变量, 后面切分文件要用
				const chunkList = [] // 保存所有切片的数组
				// 计算总共需要多少个切片
				// 为什么使用 Math.ceil？(前端面试角度)
				// - 确保即使是不足一个完整切片大小的部分也能形成一个切片
				const chunkListLength = Math.ceil(fileObj.size / chunkSize)
				// 使用正则提取文件后缀名
				// const suffix = /\.([0-9A-z]+)$/.exec(fileObj.name)[1]
				const match = /\.([0-9A-z]+)$/.exec(fileObj.name)
				const suffix = match ? match[1] : ''

				// 根据文件内容生成 hash 值
				// 为什么基于内容生成 hash？(前端面试角度)
				// - 实现秒传功能：相同内容文件生成相同 hash，避免重复上传
				// - 唯一标识文件，便于后端管理和去重
				const spark = new SparkMD5.ArrayBuffer()
				spark.append(buffer) // 将文件内容添加到 hash 计算中
				const hash = spark.end() // 完成 hash 计算

				// 生成切片, 这里后端要求传递的参数为字节数据块(chunk)和每个数据块的文件名(fileName)
				let cur = 0 // 切片时的初始位置
				// 循环生成所有切片
				for (let i = 0; i < chunkListLength; i++) {
					const item = {
						// 使用 slice 方法切割文件，得到 Blob 对象
						chunk: fileObj.slice(cur, cur + chunkSize),
						// 文件名规则按照 hash_序号.后缀 命名
						fileName: `${hash}_${i}.${suffix}`,
						name: fileObj.name,
					}
					cur += chunkSize
					chunkList.push(item)
				}
				console.log('切片完后的数组：', chunkList)
				// 保存切片列表和 hash 值供后续使用
				this.chunkList = chunkList
				this.hash = hash
			},

			/**
			 * 上传文件切片的主函数
			 * 实现功能：
			 * 1. 为每个切片创建上传请求
			 * 2. 控制上传过程（包括暂停/继续）
			 * 3. 更新上传进度
			 * 4. 所有切片上传完成后请求合并
			 *
			 * 为什么采用这种上传策略？(前端面试角度)
			 * - 串行上传而非并行：避免网络拥塞，减轻服务器压力
			 * - 支持暂停/继续：提供更好的用户体验
			 * - 实时更新进度：让用户了解上传状态
			 * - 上传成功后移除切片：实现简单的断点续传
			 */
			uploadChunks() {
				// 请求集合，存储所有上传请求函数
				const requestList = []

				// 为每个切片创建上传请求函数
				this.chunkList.forEach((item, index) => {
					// 定义单个切片的上传函数
					const fn = () => {
						// 创建 FormData 对象用于上传文件
						const formData = new FormData()
						// 添加 hash 值
						formData.append('hash', this.hash)
						// 添加切片文件
						formData.append('chunk', item.chunk)
						// 添加文件名
						formData.append('filename', item.fileName)
						// 源文件名
						formData.append('name', item.name.replace(/\.[^/.]+$/, ''))

						// 发送上传请求
						return axios({
							url: BaseUrl + '/api/upload',
							method: 'post',
							headers: { 'Content-Type': 'multipart/form-data' },
							data: formData,
						})
							.then((res) => {
								console.log('上传切片', res.data)
								// 判断上传结果,文件已经存在,则不进行合并操作
								if (res.data.code === 2001) {
									this.filePath = res.data.filePath
									this.$message.warning(`上传失败,${res.data.message}`)
									return
								}
								// 上传成功处理
								if (res.data.code === 200) {
									// 计算每个切片对总进度的贡献值
									if (this.percentCount === 0) {
										// 避免上传成功后会删除切片改变 chunkList 的长度影响到 percentCount 的值
										this.percentCount = 100 / this.chunkList.length
									}
									// 更新进度条
									this.percent += this.percentCount
									// 一旦上传成功就删除这一个 chunk, 方便断点续传
									this.chunkList.splice(index, 1)
									console.log('上传进度:', this.chunkList, this.percent)
								}
							})
							.catch((error) => {
								// 上传失败处理
								console.log('上传失败：', error)
								this.$message.error('上传失败，请检查服务端是否正常')
							})
					}
					// 将上传函数添加到请求列表
					requestList.push(fn)
				})

				let i = 0 // 记录发送的请求个数

				/**
				 * 所有切片上传完成后调用的函数
				 * 向服务端发送合并请求，将所有切片合并成完整文件
				 *
				 * 为什么需要单独的合并步骤？(前端面试角度)
				 * - 分离上传和合并两个阶段，提高系统的容错性
				 * - 确保所有切片都上传成功后再进行合并操作
				 * - 便于实现断点续传：可以只重新上传失败的切片
				 */
				const complete = () => {
					axios({
						url: BaseUrl + '/api/merge',
						method: 'post',
						// 发送合并所需的参数
						data: {
							hash: this.hash,
							filename: this.file.name,
							name: this.file.name.replace(/\.[^/.]+$/, ''),
							size: SIZE, // 注意：这里应该传切片大小而不是数量
						},
					}).then((res) => {
						// 处理合并结果
						if (res.data.code === 0) {
							this.filePath = res.data.filePath
							this.fetchList()
							// 请求发送成功
							this.$message({
								message: res.data.message,
								type: 'success',
							})
						} else {
							this.$message({
								message: res.data.message,
								type: 'error',
							})
						}
					})
				}

				/**
				 * 递归发送上传请求的函数
				 * 控制请求的发送顺序，并处理暂停/继续逻辑
				 *
				 * 为什么使用递归而非循环？(前端面试角度)
				 * - 更好地控制异步流程，确保按顺序执行
				 * - 便于实现暂停/继续功能
				 * - 代码逻辑更清晰易懂
				 */
				const send = async () => {
					// 如果暂停则停止发送请求
					if (!this.upload) return
					// 如果所有请求都已发送完毕，则执行合并操作
					if (i >= requestList.length) {
						// 全部发送完毕
						complete()
						return
					}
					// 发送当前请求并等待完成
					await requestList[i]()
					// 移动到下一个请求
					i++
					// 递归调用继续发送下一个请求
					send()
				}
				// 开始发送请求
				send()
			},

			/**
			 * 暂停/继续按钮点击事件处理函数
			 * 切换上传状态并在恢复上传时继续未完成的上传任务
			 *
			 * 为什么需要暂停/继续功能？(前端面试角度)
			 * - 提升用户体验，允许用户控制上传过程
			 * - 节省带宽资源，在需要时暂停上传
			 * - 展示对复杂交互的处理能力
			 */
			handleClickBtn() {
				// 切换上传状态
				this.upload = !this.upload
				// 如果不暂停则继续上传
				if (this.upload) this.uploadChunks()
			},
		},
	}

	/**
	 * 前端面试重点解析
1. 为什么采用分片上传方案？
面试要点：

大文件处理：避免大文件上传超时或失败
网络稳定性：单个切片失败只需重传该切片
用户体验：可以显示具体进度，提供暂停/继续功能
服务器压力：分散上传压力，避免瞬时大量数据冲击
2. 为什么使用 MD5 Hash 做文件标识？
面试要点：

秒传功能：相同文件内容生成相同 hash，避免重复上传
唯一性保证：不同文件几乎不可能有相同 hash
断点续传：通过 hash 标识文件，便于恢复上传状态
去重机制：服务端可以根据 hash 判断文件是否已存在
3. 为什么选择串行而非并行上传？
面试要点：

网络资源控制：避免同时发起过多请求造成网络拥塞
服务器压力：减轻服务端并发处理压力
简化逻辑：串行处理更容易控制暂停/继续逻辑
有序处理：虽然并行更快，但在大多数场景下串行已足够
4. 为什么需要暂停/继续功能？
面试要点：

用户体验：用户可以主动控制上传过程
资源节约：在必要时暂停上传，节省带宽
复杂交互：体现对异步流程控制的能力
实际需求：在移动端或网络不稳定的环境中非常实用
5. 为什么使用递归而非循环处理请求序列？
面试要点：

异步控制：递归更容易控制异步操作的顺序执行
状态管理：便于在递归过程中插入暂停/继续逻辑
代码可读性：相比复杂的循环+状态标志，递归逻辑更清晰
错误处理：每一步都可以独立处理错误并决定是否继续
	 */
</script>

<style>
	.file-upload {
		margin-top: 50px;
		margin-left: 50px;
	}

	.progress-box {
		box-sizing: border-box;
		width: 360px;
		display: flex;
		justify-content: space-between;
		align-items: center;
		margin-top: 10px;
		padding: 8px 10px;
		background-color: #ecf5ff;
		font-size: 14px;
		border-radius: 4px;
	}
</style>
